# Copyright 2007 Google Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.


"""working copy stuff

Classes:
WorkingCopy     -- Representation of a working copy
Edit            -- commit.EditorAction to apply a change from a working copy
ChangeState     -- Representation of a path's changebranch state
PostfixTextDeltaManager -- Postfix text-delta transmission manager

Functions:
FindWorkingCopy -- Return a WorkingCopy for the top of a working copy path
StatusString    -- Return a string suitable for printing about an entry

Objects:
notify_postfix_txdeltas_completed  -- Hack for gvn.cmdline.Context.Notify

"""


import cPickle
import md5
import os
import posixpath
import sys

from errno import ENOENT
from errno import EEXIST
from tempfile import mkstemp

import svn.wc

from svn.core import SubversionException, SVN_ERR_WC_NOT_DIRECTORY
from svn.core import svn_node_dir, svn_node_file

import gvn.commit
import gvn.config
import gvn.errors
import gvn.project
import gvn.util


STATE_FILE = '.gvnstate'


def _GetAccessEntry(path, cancel_func, write_lock, depth, pool):
  """Return svn_wc_adm_access_t and svn_wc_entry_t for path.

  Arguments:
  path        -- path to open
  cancel_func -- cancellation callback
  write_lock  -- whether to take out a write lock
  depth       -- -1: lock from path down; 0: just lock path; N: how many
                 directories deep to lock
  pool        -- memory pool

  """

  (anchor_access, target_access,
   target) = svn.wc.adm_open_anchor(path, write_lock, depth, cancel_func,
                                    pool)
  return (anchor_access,
          svn.wc.entry(path, anchor_access,
                       False,           # show_hidden
                       pool))


class ChangeState(object):
  """Representation of a path's changebranch state

  This is meta-data about the working file (not text-base) as last
  snapshotted.

  """

  def __init__(self, change_name, path, checksum, status):
    """Initialize from change_name, path, checksum, svn_wc_status2_t."""

    self.change_name = change_name

    # Copy from svn_wc_entry_t.
    self.kind = status.entry.kind
    self.copyfrom_url = status.entry.copyfrom_url
    self.copyfrom_rev = status.entry.copyfrom_rev
    self.has_prop_mods = status.entry.has_prop_mods

    # Copy from svn_wc_status2_t.
    self.text_status = status.text_status
    self.prop_status = status.prop_status

    self.checksum = checksum

    if status.text_status == svn.wc.status_deleted:
      self.mtime = self.size = None
    else:
      st = os.stat(path)
      self.mtime = st.st_mtime
      self.size = st.st_size

  def __eq__(self, other):
    for i in ['kind', 'copyfrom_url', 'copyfrom_rev', 'has_prop_mods',
              'mtime', 'size']:
      if getattr(self, i) != getattr(other, i):
        return False
    return True

  def __ne__(self, other):
    return not self == other


class WorkingCopy(object):
  def __init__(self, path, cancel_func, notify_func, config, pool):
    """Initialize and open from str, callable, callable, Config, Pool.

    Arguments:
    path        -- absolute path to a working copy top
    cancel_func -- callback()
    notify_func -- callback(svn_wc_notify_t, pool)
    config      -- Config object
    pool        -- memory pool

    """

    # absolute path to top of working copy
    self._path = path

    # stuff for svn.wc
    self._cancel_func = cancel_func
    self._notify_func = notify_func
    self._config = config
    self._pool = pool

    self._adm_access = self._entry = self._write_lock = self._recursive = None
    self._committed = []
    self._wcprop_changes = {}

    self._change_state = {}
    # Use this second change change_state dict for pending changes, i.e.
    # those that will be written to the change_state when it is saved.
    # Keeping this separate and clearing it on error ensure change_state is
    # not inconsistent with actual change state.  A value of None marks an
    # item to be removed from the change_state.
    self._pending_change_state = {}

    self.Load()

    # Get URL from quick non-recursive wc open.
    (adm_access, entry) = _GetAccessEntry(path, cancel_func, depth=0,
                                          write_lock=False, pool=self._pool)
    self._repository_root = entry.repos
    self._url = entry.url
    self._base = self._url[len(self.repository_root)+1:]
    svn.wc.adm_close(adm_access)

    # Get ProjectConfig and Project from URL.
    try:
      self._project_config = self._config.ProjectByURL(self._url)
    except gvn.errors.NoProject:
      self._project_config = gvn.config.ProjectConfig(
                                                 self._config.default_username,
                                                 self._url)
    root = self.repository_root
    self._project = gvn.project.Project(self._project_config.username, root,
                                        self._config, self._pool,
                                        self._project_config.URL[len(root)+1:])
    self._project.repository.have_root_url = True
    self._project.repository.ra_callbacks.SetWC(self)

  name = property(lambda self: os.path.basename(self._base))
  path = property(lambda self: self._path)
  cancel_func = property(lambda self: self._cancel_func,
                        lambda self, f: setattr(self, '_cancel_func', f))
  notify_func = property(lambda self: self._notify_func,
                        lambda self, f: setattr(self, '_notify_func', f))

  project = property(lambda self: self._project)
  project_config = property(lambda self: self._project_config)

  change_state = property(lambda self: self._change_state,
                       doc="""Map of working copy path to ChangeState.""")

  repository_root = property(lambda self: self._repository_root)

  def Load(self):
    """Load STATE_FILE file."""

    try:
      fp = open(os.path.join(self.path, STATE_FILE))
    except IOError, e:
      if e.errno == ENOENT:
        return
      raise

    # Do we want to complain about corrupt state files?  Maybe a warning.
    try:
      p = cPickle.load(fp)
    except EOFError:
      return

    try:
      d = p['change_state']
    except (KeyError, TypeError):
      d = {}
    self.change_state.update(d)

  def UpdateChangeState(self, path, state=None, **attrs):
    """Schedule a change_state update for path.

    Arguments:
    path        -- path whose state to update
    state       -- optional new ChangeState object

    Additionally, attribute names of ChangeState may be passed as
    keyword arguments to update specific fields.  For example, to move
    'foo' from one changebranch to another:

      wc.UpdateChangeState('foo', change_name='new_change')

    Raises:
    AttributeError      -- if state argument is None and change_state
                           for path has been deleted;
                           or if a non-existent ChangeState attribute
                           has been passed as a keyword argument
    KeyError            -- if state argument is None and no change_state
                           for path has been set
    """
    if state is not None:
      self._pending_change_state[path] = state
    for (attr, value) in attrs.iteritems():
      setattr(self._pending_change_state[path], attr, value)

  def DeleteChangeState(self, path):
    """Schedule a deletion of change_state for path."""
    self._pending_change_state[path] = None

  def ClearPendingChangeState(self):
    """Clear all pending change_state.

    Call this on error when changing a changebranch.
    """
    self._pending_change_state.clear()

  def Save(self):
    """Flush pending change_state and save STATE_FILE file."""

    for (path, state) in list(self._pending_change_state.iteritems()):
      if state is None:
        del self._change_state[path]
        del self._pending_change_state[path]
    self._change_state.update(self._pending_change_state)
    self.ClearPendingChangeState()

    (fd, fn) = mkstemp(dir=self.path)
    fp = os.fdopen(fd, 'w')
    cPickle.dump({
      'change_state': self.change_state,
      }, fp)
    fp.close()
    state_file = os.path.join(self.path, STATE_FILE)
    try:
      os.rename(fn, state_file)
    except OSError, e:
      if sys.platform != 'win32' or e.errno != EEXIST:
        raise
      # "On Windows, if dst already exists, OSError will be raised even if it
      # is a file; there may be no way to implement an atomic rename when dst
      # names an existing file."
      os.remove(state_file)
      os.rename(fn, state_file)

  # TODO(epg): see what else should use adm_access instead of _adm_access
  def _GetAdmAccess(self):
    if self._adm_access is None:
      raise gvn.errors.WCClosed
    return self._adm_access
  adm_access = property(_GetAdmAccess,
doc="""svn_wc_adm_access_t if WorkingCopy is Open

       Raises:
       gvn.errors.WCClosed
""")

  def AdmRetrieve(self, path, pool):
    # TODO(epg): This is duplicated all over the place; make them all
    # call this instead.

    # This is stupid; I have to stat the file just to get an adm baton?!
    if os.path.isdir(path):
      (directory, target) = (path, '')
    else:
      (directory, target) = os.path.split(path)
    if directory == self.path:
      return self.adm_access
    return svn.wc.adm_retrieve(self.adm_access, directory, pool)

  def GetPropDiffs(self, path, pool):
    return svn.wc.get_prop_diffs(path.encode('utf8'),
                                 self.AdmRetrieve(path, pool), pool)

  def Open(self, paths=None, write_lock=False, recursive=True):
    """Open the working copy to deepest common of paths.

    Arguments:
    paths      -- paths whose deepest common path to open; default [self.path]
    write_lock -- bool whether to take out write lock; default False
    recursive  -- bool whether to open recursively; default True

    Raises:
    gvn.errors.WCReOpen

    """

    if self._adm_access is not None:
      if self._write_lock != write_lock or self._recursive != recursive:
        raise gvn.errors.WCReOpen(self._write_lock, write_lock,
                                  self._recursive, recursive)
      return

    if paths is None:
      path = self.path
    else:
      path = gvn.util.CommonPrefix([self.AbsolutePath(x) for x in paths])

    self._write_lock = write_lock
    self._recursive = recursive

    if recursive:
      depth = -1
    else:
      depth = 0

    # path may be a new directory scheduled for addition, whose entry
    # is not complete; specifically, it has no base revision, which we
    # need in order to changebranch.  So, climb up from path until we
    # find a complete entry.
    # TODO(epg): Actually, we only need to do this when
    # changebranching; for opened, submit, etc. it's wasted effort;
    # maybe Open should take an option for skipping this?
    while True:
      (adm_access, entry) = _GetAccessEntry(path, self.cancel_func,
                                            self._write_lock, depth,
                                            self._pool)
      if entry is None:
        raise gvn.errors.NotVersioned(path)
      if entry.revision > 0:
        break
      path = os.path.dirname(path)
      svn.wc.adm_close(adm_access)

    self._adm_access = adm_access
    self._entry = entry
    self.subpath = self.RelativePath(path)

  def Close(self):
    """Close the working copy, releasing any locks.

    Note that this WorkingCopy remains valid, though some methods will
    not work until .Open is called again.  See the individual method
    docstrings for that information.

    Raises:
    gvn.errors.WCClosed
    """

    if self._adm_access is None:
      raise gvn.errors.WCClosed

    svn.wc.adm_close(self._adm_access)
    self._adm_access = self._entry = self._write_lock = self._recursive = None

  def AbsolutePath(self, path=None):
    """Return the absolute path of path in the working copy.

    Arguments:
    path -- path to make absolute; default self.path
    """
    if path is None or path == '':
      return self.path
    return '/'.join([self.path, path])

  def RelativePath(self, path):
    """Return path relative to the working copy for path."""
    return gvn.util.RelativePath(self.path, path)

  def RepoPath(self, path=''):
    """Return the path in the repository for path."""
    if self._base == '':
      return path
    if path == '':
      return self._base
    return '/'.join([self._base, path])

  def URL(self, path=''):
    """Return the repository URL for path."""
    return self.project.repository.URL(self.RepoPath(path))

  def LocalPath(self, path):
    """Return the relative path in the working copy for repository path."""
    try:
      return gvn.util.RelativePath(self._url, path)
    except gvn.errors.PathNotChild:
      return gvn.util.RelativePath(self._base, path)

  def Notify(self, path, action, kind=None, pool=None):
    """Notify caller of some action on path.

    This is a convenience wrapper around the svn_wc_notify_func2_t
    provided at instantiation.

    Arguments:
    path            -- internal_style str being acted upon
    action          -- svn_wc_notify_action_t of path
    kind            -- svn_node_kind_t of path (default None)
    pool            -- memory pool
    """
    if callable(self._notify_func):
      n = svn.wc.create_notify(path, action, pool)
      if kind is not None:
        n.kind = kind
      self._notify_func(n, pool)

  def Entry(self, path=None, pool=None):
    """Return svn_wc_entry_t for path (default self.subpath).

    Arguments:
    path -- path in the working copy (default self.subpath)
    pool -- pool for local processing

    Raises:
    gvn.errors.WCClosed
    """
    if self._adm_access is None:
      raise gvn.errors.WCClosed

    if path in [None, self.subpath]:
      return self._entry

    return svn.wc.entry(self.AbsolutePath(path), self._adm_access,
                        True,           # show_hidden
                        pool)

  def Status(self, paths=[''], recursive=False, get_all=True, no_ignore=True,
             show_children=True,
             callback=None):
    """Return a list of (or call callback for) svn_wc_status2_t objects for paths.

    Arguments:
    paths         -- list of paths for which to get status
    recursive     -- whether to descend into children of paths
    get_all       -- whether to get status for unmodified files
    no_ignore     -- whether to get status for ignored files
    show_children -- report children of deleted directories
                     when not explicitly specified in paths (default=True)
    callback      -- if provided, call with status instead of returning a list

    """

    if not callable(callback):
      result = []

    if len(paths) == 0:
      paths = ['']
    for i in paths:
      path = self.AbsolutePath(i)

      # This is stupid.
      if os.path.isdir(path):
        (directory, target) = (path, '')
      else:
        (directory, target) = os.path.split(path)

      adm_access = svn.wc.adm_retrieve(self._adm_access, directory, self._pool)

      deleted_trees = []
      def status_func(target, status):
        # For whatever reason, this is sometimes called with some (but
        # not all) children of path even when recursive is False.
        if not recursive and target != path:
          return

        if self.RelativePath(target) == STATE_FILE:
          return

        if not show_children:
          # What we're trying to do here is turn this:
          # 0 gvn% svn mv testdata foo
          # 0 gvn% gvn opened
          # D      testdata
          # D      testdata/hooks
          # A  +   foo
          # into this
          # 0 gvn% gvn opened
          # D      testdata
          # A  +   foo
          for tree in deleted_trees:
            if gvn.util.IsChild(target, tree):
              if status.text_status == svn.wc.status_deleted:
                # target (e.g. testdata/hooks) is a child of tree
                # (e.g. testdata) and is deleted; hide it.
                return
          if (status.entry is not None
              and status.entry.kind == svn_node_dir
              and status.text_status == svn.wc.status_deleted):
            # target is a deleted tree; save it so we can hide its children.
            deleted_trees.append(target)

        # I'd love to do this, but it doesn't work.  Search the web for
        # [python callback generator] and you'll find that the only
        # thing people have to suggest is threads.  Blech.
        #yield status

        # So we callback or build and return a list.
        if callable(callback):
          return callback(target, status)
        result.append((target, status))

      (editor, edit_baton, set_locks_baton,
       edit_revision) = svn.wc.get_status_editor2(adm_access,
                                                  target,
                                                  self._config.svn_config_hash,
                                                  recursive,
                                                  get_all,
                                                  no_ignore,
                                                  status_func,
                                                  self.cancel_func,
                                                  None, # traversal_info
                                                  self._pool)
      editor.close_edit(edit_baton, self._pool)

    if not callable(callback):
      return result

  def WCPropSet(self, path, name, value):
    """Save value of wcprop name for path for post-commit processing."""
    try:
      d = self._wcprop_changes[path]
    except KeyError:
      d = self._wcprop_changes[path] = {}
    d[name] = value

  def ProcessCommitted(self, path, recursive, kind, remove_lock, checksum):
    """Append a committed item to the post-commit processing queue."""
    self._committed.append((path, recursive, kind, remove_lock, checksum))

  def ProcessCommittedQueue(self, commit_info, pool):
    """Post-process committed items."""

    iterpool = svn.core.Pool(pool)
    for i in self._committed:
      iterpool.clear()
      args = list(i)
      kind = args.pop(2)
      relative = args[0]
      args[0] = self.AbsolutePath(args[0])

      if kind == svn_node_dir:
        adm_access_path = args[0]
        parent = os.path.dirname(adm_access_path)
        if parent == self.path:
          base_dir_access = self._adm_access
        else:
          base_dir_access = svn.wc.adm_retrieve(self._adm_access,
                                                parent,
                                                iterpool)
      else:
        base_dir_access = self._adm_access
        adm_access_path = os.path.dirname(args[0])

      args.insert(1, svn.wc.adm_retrieve(base_dir_access,
                                         adm_access_path,
                                         iterpool))

      args.insert(3, commit_info.revision)
      args.insert(4, commit_info.date)
      args.insert(5, commit_info.author)
      args.insert(6, self._wcprop_changes.get(relative))
      args.append(iterpool)
      svn.wc.process_committed3(*args)
    iterpool.destroy()

    del self._committed[:]
    self._wcprop_changes.clear()

  def NeedsSnapshot(self, path, status, pool):
    """Return whether path needs snapshotting.

    Arguments:
    path        -- path in working copy to be tested
    status      -- svn_wc_status2_t for path
    pool        -- used for temporary allocations

    """

    try:
      old = self.change_state[path]
    except KeyError:
      raise gvn.errors.NotChangeBranched(path)

    abs_path = self.AbsolutePath(path)

    new = ChangeState(None, abs_path, checksum=None, status=status)

    # If kind has changed, no question this needs snapshot.
    if new.kind != old.kind:
      return True

    # Now, let's be kinda like svn_wc_text_modified_p.

    # XXX(epg): svn_wc_text_modified_p returns FALSE if old == new.
    # It only returns TRUE if old != new and a full file compare finds
    # differences.  Seems odd to me; if old != new, we *know* the file
    # is modified, so why do we checksum at all?

    if old == new:
      return False

    if status.entry.kind != svn_node_file:
      # If it's not a file, no checksumming, they're just different.
      return True

    # It's a file that may be different, let's compare.

    # Here's the tricky bit: old.checksum is of the *normal* form;
    # i.e. without keyword expansion, eol translation, or other forms
    # of text mangling.  Man i hate that stuff.  So we have to
    # checksum the normal form of the current working file.
    adm_access = svn.wc.adm_probe_retrieve(self._adm_access,
                                           abs_path, pool)
    stream = svn.wc.translated_stream(abs_path, abs_path, adm_access,
                                      svn.wc.TRANSLATE_TO_NF, pool)
    m = md5.new()
    while True:
      data = svn.core.svn_stream_read(stream, svn.core.SVN_STREAM_CHUNK_SIZE)
      if not data:
        break
      m.update(data)

    return old.checksum != m.digest()

def FindWorkingCopy(path, cancel_func, notify_func, config, pool=None):
  """Return (WorkingCopy, subdir) for the top of the working copy path.

  The second element of the returned tuple is the path within
  WorkingCopy.path for path, examples:

    '/tmp/wc'       => (WorkingCopy, '')
    '/tmp/wc/lib'   => (WorkingCopy, 'lib')

  Arguments:
  path        -- absolute, internal-style path to a working copy top
  cancel_func -- callback()
  notify_func -- callback(svn_wc_notify_t, pool)
  config      -- Config object
  pool        -- memory pool

  """

  # First, find the deepest path at path or above that is a
  # working copy.
  orig_uuid_url = [None, None]
  def is_wc(d):
    try:
      (adm_access, entry) = _GetAccessEntry(d, cancel_func, write_lock=False,
                                            depth=0, pool=pool)
    except SubversionException, e:
      if e.apr_err != SVN_ERR_WC_NOT_DIRECTORY:
        raise
      # Keep looking.
      return -1
    if entry is None or entry.uuid is None:
      svn.wc.adm_close(adm_access)
      # Keep looking.
      return -1
    orig_uuid_url[:] = (entry.uuid, entry.url)
    svn.wc.adm_close(adm_access)
    return 0
  curr = gvn.util.ClimbAndFind(path, is_wc)

  # Did we find anything?
  if orig_uuid_url[0] is None:
    # path is not in a wc at all; bail
    raise gvn.errors.NotWC(path)

  # Now let's look for the shallowest path at path or above that is a
  # working copy of the same repository.
  def is_top_wc(d):
    try:
      (adm_access, entry) = _GetAccessEntry(d, cancel_func, write_lock=False,
                                            depth=0, pool=pool)
    except SubversionException, e:
      if e.apr_err != SVN_ERR_WC_NOT_DIRECTORY:
        raise
      # We've walked up out of the wc completely, return one deeper.
      return 1

    if entry is None or [entry.uuid, entry.url] != orig_uuid_url:
      # We've walked up into some other repository checkout, or not a
      # working copy at all, or out of an external; return one deeper.
      svn.wc.adm_close(adm_access)
      return 1

    # Keep looking.
    svn.wc.adm_close(adm_access)
    # So eat the last path component of the saved URL
    orig_uuid_url[1] = posixpath.dirname(orig_uuid_url[1])
    return -1
  top = gvn.util.ClimbAndFind(curr, is_top_wc)

  subpath = gvn.util.RelativePath(top, path)
  return (WorkingCopy(top, cancel_func, notify_func, config, pool), subpath)


def IsBroken(status):
  """Return whether the svn_wc_status2_t indicates a broken node."""
  states = [svn.wc.status_missing, svn.wc.status_incomplete,
            svn.wc.status_obstructed]
  return status.text_status in states or status.prop_status in states

def IsConflicted(status):
  """Return whether the svn_wc_status2_t indicates a conflicted node."""
  return svn.wc.status_conflicted in [status.text_status, status.prop_status]

def IsModified(status):
  """Return whether the svn_wc_status2_t indicates a modified node."""
  states = [svn.wc.status_added, svn.wc.status_conflicted,
            svn.wc.status_deleted, svn.wc.status_modified,
            svn.wc.status_replaced]
  return (status.text_status in states or status.prop_status in states
          or (status.entry is not None
              and (status.entry.copied
                   or status.entry.deleted)
              ))


_status_codes = {
  svn.wc.status_none:        ' ',
  svn.wc.status_normal:      ' ',
  svn.wc.status_added:       'A',
  svn.wc.status_missing:     '!',
  svn.wc.status_incomplete:  '!',
  svn.wc.status_deleted:     'D',
  svn.wc.status_replaced:    'R',
  svn.wc.status_modified:    'M',
  svn.wc.status_merged:      'G',
  svn.wc.status_conflicted:  'C',
  svn.wc.status_obstructed:  '~',
  svn.wc.status_ignored:     'I',
  svn.wc.status_external:    'X',
  svn.wc.status_unversioned: '?',
}
def _StatusCode(status):
    return _status_codes.get(status, '?')
def StatusString(path, status):
  """Return a string suitable for printing about this status entry.

  The intent is to duplicate the actual svn client syntax.

  """

  columns = [_StatusCode(status.text_status), _StatusCode(status.prop_status),
             ' ', ' ', ' ', ' ']

  if status.locked:
    columns[2] = 'L'

  if status.entry is not None and status.entry.copied:
    columns[3] = '+'

  if status.switched:
    columns[4] = 'S'

  return ' '.join([''.join(columns), path])

def ActionCode(status):
  """Return single-letter code for svn_wc_status2_t."""
  if status.text_status == svn.wc.status_added:
    return _status_codes[svn.wc.status_added]
  if status.text_status == svn.wc.status_deleted:
    return _status_codes[svn.wc.status_deleted]
  return _status_codes[svn.wc.status_modified]


class Edit(gvn.commit.EditorAction):
  """Apply a change from a working copy; return a baton if adding a directory.

  Raises:
  SubversionException.apr_err==SVN_ERR_FS_NOT_DIRECTORY
  SubversionException.apr_err==SVN_ERR_RA_DAV_PATH_NOT_FOUND
  SubversionException.apr_err==???
  ???

  """

  def __init__(self, wc, wc_path, status, file_baton_cb=None):
    """Initialize Edit.

    Arguments:
    wc         -- WorkingCopy object
    wc_path    -- path in the working copy
    status     -- svn_wc_status2_t for wc_path
    file_baton_cb       -- optional callback(file baton, fulltext, wc_path,
                                             svn_wc_status2_t) for callers
                           to save these objects for use in postfix
                           text-delta transmission (usually
                           PostfixTextDeltaManager.Add)
    """

    self.wc = wc
    self.wc_path = wc_path
    self.status = status
    if file_baton_cb is None:
      def file_baton_cb(*args): pass
    self.file_baton_cb = file_baton_cb
    self.tempfile = None

  # TODO(epg): changebranch._WCBranch needs to override this
  def DiagLog(self, format, *args):
    """Call gvn.commit.EditorAction.DiagLog, prepending attributes from init."""
    return gvn.commit.EditorAction.DiagLog(
      self, '(%s, %s, %s) => %s' % (self.wc_path, self.status,
                                    self.file_baton_cb, format),
      *args)

  def _GetAction(self):
    if (self.status.text_status == svn.wc.status_normal
        and self.status.prop_status != svn.wc.status_normal):
      status = self.status.prop_status
    else:
      status = self.status.text_status
    actions = {
      svn.wc.status_added:       self._Added,
      svn.wc.status_deleted:     self._Deleted,
      svn.wc.status_replaced:    self._Replaced,
      svn.wc.status_modified:    self._Modified,
    }
    return actions[status]

  def _PostProcess(self, pool):
    """Post-process after file delete or any directory operation.

    This implementation calls self.wc.ProcessCommitted to save this
    path for the post-commit wc bump.

    Do not call any file operation except delete; instead use the
    file_baton_cb.
    """
    self.wc.ProcessCommitted(self.wc_path,
                    recursive=(self.status.text_status == svn.wc.status_added
                               and self.status.entry.kind == svn_node_dir
                               and self.status.entry.copyfrom_url is not None),
                             kind=self.status.entry.kind,
                             remove_lock=False, checksum=None)

  def __call__(self, parent, path, editor, pool):
    return self._GetAction()(parent, path, editor, pool)

  def _TransmitPropDeltas(self, baton, editor, pool):
    """Transmit property deltas into baton."""
    abs_path = self.wc.AbsolutePath(self.wc_path)
    adm_access = svn.wc.adm_probe_retrieve(self.wc._adm_access,
                                           abs_path, pool)
    svn.wc.transmit_prop_deltas(abs_path, adm_access, self.status.entry,
                                editor, baton, pool)

  def _AddHelper(self, parent, path, editor,
                 copyfrom_url=None, copyfrom_rev=None, pool=None):
    if copyfrom_url is None:
      copyfrom_url = self.status.entry.copyfrom_url
      copyfrom_rev = self.status.entry.copyfrom_rev

    if self.status.entry.kind == svn_node_dir:
      args = (path, parent, copyfrom_url, copyfrom_rev, pool)
      gvn.DiagLog('add_directory%s', args)
      baton = editor.add_directory(*args)
      self._TransmitPropDeltas(baton, editor, pool)
      self._PostProcess(pool)
    else:
      args = (path, parent, copyfrom_url, copyfrom_rev, pool)
      gvn.DiagLog('add_file%s', args)
      baton = editor.add_file(*args)
      self._TransmitPropDeltas(baton, editor, pool)
      self.file_baton_cb(self.wc_path, baton,
                         fulltext=True, status=self.status)
      baton = None
    return baton

  def _Added(self, parent, path, editor, pool):
    self.wc.Notify(self.wc_path, svn.wc.notify_commit_added,
                   self.status.entry.kind, pool)
    self.DiagLog('')
    result = self._AddHelper(parent, path, editor, pool=pool)
    gvn.DiagLog('\n')
    return result

  def _Deleted(self, parent, path, editor, pool):
    self.wc.Notify(self.wc_path, svn.wc.notify_commit_deleted, pool=pool)
    args = (path, self.status.entry.revision, parent, pool)
    self.DiagLog('delete_entry%s\n', args)
    editor.delete_entry(*args)
    self._PostProcess(pool)
    return None

  def _Replaced(self, parent, path, editor, pool):
    self.wc.Notify(self.wc_path, svn.wc.notify_commit_replaced, pool=pool)
    args = (path, self.status.entry.revision, parent, pool)
    gvn.DiagLog('delete_entry%s; ', args)
    editor.delete_entry(*args)
    result = self._AddHelper(parent, path, editor, pool=pool)
    gvn.DiagLog('\n')
    return result

  def _Modified(self, parent, path, editor, pool):
    self.wc.Notify(self.wc_path, svn.wc.notify_commit_modified,
                   self.status.entry.kind, pool)
    if self.status.entry.kind == svn_node_dir:
      args = (path, parent, self.status.entry.revision, pool)
      self.DiagLog('open_directory%s\n', args)
      baton = editor.open_directory(*args)
      self._TransmitPropDeltas(baton, editor, pool)
      self._PostProcess(pool)
    else:
      args = (path, parent, self.status.entry.revision, pool)
      self.DiagLog('open_file%s\n', args)
      baton = editor.open_file(*args)
      self._TransmitPropDeltas(baton, editor, pool)
      self.file_baton_cb(self.wc_path, baton,
                         fulltext=False, status=self.status)
      baton = None
    return baton

#: We have svn_wc_notify for update_completed, and status_completed, but not
#: for txdeltas_completed.  TODO(epg): Add this to svn_wc_notify.
notify_postfix_txdeltas_completed = object()

class PostfixTextDeltaManager(object):
  def __init__(self, wc):
    """Initialize from WorkingCopy object."""
    self.wc = wc
    self.files = []

  def Add(self, wc_path, baton, fulltext, status):
    """Save objects for postfix text-delta transmission.

    Arguments:
    wc_path             -- path relative to working copy top
    baton               -- file baton from add_file or open_file
    fulltext            -- whether to send full-text of wc_path or delta
                           from text-base
    status              -- svn_wc_status2_t for wc_path
    """
    self.files.append((baton, fulltext, wc_path, status))

  def Transmit(self, editor, edit_baton, pool):
    """Transmit text-deltas for all self.Add()ed paths.

    Arguments:
    editor              -- svn_delta_editor_t
    edit_baton          -- baton from editor open
    pool                -- memory pool
    """
    if len(self.files) == 0:
      return
    for (baton, fulltext, wc_path, status) in self.files:
      self.wc.Notify(wc_path, svn.wc.notify_commit_postfix_txdelta, pool=pool)
      abs_path = self.wc.AbsolutePath(wc_path)
      adm_access = svn.wc.adm_probe_retrieve(self.wc._adm_access,
                                             abs_path, pool)
      (tmp, checksum) = svn.wc.transmit_text_deltas2(abs_path, adm_access,
                                                     fulltext, editor,
                                                     baton, pool)
      self.HandleTmp(tmp)
      self.PostProcess(wc_path, status, checksum)
    # TODO(epg): Change self.wc.Notify so that we can use it instead of
    # poking behind its back; though this is moot if we don't need this
    # custom sentinel at all...
    if callable(self.wc._notify_func):
      self.wc._notify_func(notify_postfix_txdeltas_completed)

  def PostProcess(self, wc_path, status, checksum):
    """Save wc_path and meta-data for wc bump.

    Subclasses may want to override this to perform some other
    post-processing instead (see gvn.changebranch.PostfixTextDeltaManager).

    Arguments:
    wc_path             -- path relative to working copy top
    status              -- svn_wc_status2_t for wc_path
    checksum            -- checksum of transmitted text
    """
    self.wc.ProcessCommitted(wc_path,
                         recursive=(status.text_status == svn.wc.status_added
                                    and status.entry.kind == svn_node_dir
                                    and status.entry.copyfrom_url is not None),
                             kind=status.entry.kind,
                             remove_lock=False, checksum=checksum)

  def HandleTmp(self, tmp):
    """Do nothing with tmp file.

    On a real commit (i.e. one that should mark the working copy files as
    committed), we leave this file (.svn/tmp/text-base/rho.svn-base) alone,
    and it becomes the new text-base.  Subclasses can override this to
    decide what to do (see gvn.changebranch.PostfixTextDeltaManager).
    """
    pass
